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 { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ImageUpload } from "../../../components/ImageUpload";
|
||||||
import {
|
import {
|
||||||
useAdminGlobalItem,
|
useAdminGlobalItem,
|
||||||
useDeleteAdminGlobalItem,
|
useDeleteAdminGlobalItem,
|
||||||
useUpdateAdminGlobalItem,
|
useUpdateAdminGlobalItem,
|
||||||
} from "../../../hooks/useAdminGlobalItems";
|
} from "../../../hooks/useAdminGlobalItems";
|
||||||
import { apiGet } from "../../../lib/api";
|
import { apiGet, apiPost } from "../../../lib/api";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin/items/$itemId")({
|
export const Route = createFileRoute("/admin/items/$itemId")({
|
||||||
component: AdminItemEditPage,
|
component: AdminItemEditPage,
|
||||||
@@ -114,13 +115,21 @@ function AdminItemEditPage() {
|
|||||||
category: "",
|
category: "",
|
||||||
weightGrams: "",
|
weightGrams: "",
|
||||||
priceCents: "",
|
priceCents: "",
|
||||||
|
imageFilename: "",
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
|
dominantColor: "",
|
||||||
|
cropZoom: null as number | null,
|
||||||
|
cropX: null as number | null,
|
||||||
|
cropY: null as number | null,
|
||||||
description: "",
|
description: "",
|
||||||
sourceUrl: "",
|
sourceUrl: "",
|
||||||
imageCredit: "",
|
imageCredit: "",
|
||||||
imageSourceUrl: "",
|
imageSourceUrl: "",
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
});
|
});
|
||||||
|
const [fetchUrl, setFetchUrl] = useState("");
|
||||||
|
const [fetchingImage, setFetchingImage] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Populate form when item loads
|
// Populate form when item loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,7 +141,12 @@ function AdminItemEditPage() {
|
|||||||
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
|
||||||
priceCents:
|
priceCents:
|
||||||
item.priceCents != null ? String(item.priceCents / 100) : "",
|
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 ?? "",
|
description: item.description ?? "",
|
||||||
sourceUrl: item.sourceUrl ?? "",
|
sourceUrl: item.sourceUrl ?? "",
|
||||||
imageCredit: item.imageCredit ?? "",
|
imageCredit: item.imageCredit ?? "",
|
||||||
@@ -142,6 +156,28 @@ function AdminItemEditPage() {
|
|||||||
}
|
}
|
||||||
}, [item]);
|
}, [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
|
// Fetch manufacturers for dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<Manufacturer[]>("/api/manufacturers")
|
apiGet<Manufacturer[]>("/api/manufacturers")
|
||||||
@@ -171,7 +207,11 @@ function AdminItemEditPage() {
|
|||||||
category: form.category || null,
|
category: form.category || null,
|
||||||
weightGrams: weightGrams,
|
weightGrams: weightGrams,
|
||||||
priceCents: priceCents,
|
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,
|
description: form.description || null,
|
||||||
sourceUrl: form.sourceUrl || null,
|
sourceUrl: form.sourceUrl || null,
|
||||||
imageCredit: form.imageCredit || null,
|
imageCredit: form.imageCredit || null,
|
||||||
@@ -246,21 +286,59 @@ function AdminItemEditPage() {
|
|||||||
<form onSubmit={handleSave}>
|
<form onSubmit={handleSave}>
|
||||||
{/* Image section */}
|
{/* Image section */}
|
||||||
<div>
|
<div>
|
||||||
{item.imageUrl && (
|
<label className={labelClass}>Image</label>
|
||||||
<img
|
<ImageUpload
|
||||||
src={item.imageUrl}
|
value={form.imageFilename}
|
||||||
alt={`${item.brand} ${item.model}`}
|
imageUrl={form.imageUrl || null}
|
||||||
className="w-full h-48 object-contain rounded-lg bg-gray-50 mb-3"
|
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 */}
|
||||||
<label className={labelClass}>Image URL</label>
|
<div className="mt-3">
|
||||||
|
<label className={`${labelClass} text-gray-500`}>
|
||||||
|
Or fetch from URL
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={form.imageUrl}
|
value={fetchUrl}
|
||||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
onChange={(e) => setFetchUrl(e.target.value)}
|
||||||
className={inputClass}
|
className={`${inputClass} flex-1`}
|
||||||
placeholder="https://..."
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Brand + Model */}
|
{/* Brand + Model */}
|
||||||
@@ -355,7 +433,7 @@ function AdminItemEditPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className={labelClass}>Source URL</label>
|
<label className={labelClass}>Product Page URL</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={form.sourceUrl}
|
value={form.sourceUrl}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
listGlobalItemsForAdmin,
|
listGlobalItemsForAdmin,
|
||||||
updateGlobalItemById,
|
updateGlobalItemById,
|
||||||
} from "../services/global-item.service.ts";
|
} from "../services/global-item.service.ts";
|
||||||
|
import { getImageUrl } from "../services/storage.service.ts";
|
||||||
|
|
||||||
type Env = { Variables: { db?: any; userId?: number } };
|
type Env = { Variables: { db?: any; userId?: number } };
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ const updateGlobalItemAdminSchema = z.object({
|
|||||||
weightGrams: z.number().positive().nullable().optional(),
|
weightGrams: z.number().positive().nullable().optional(),
|
||||||
priceCents: z.number().int().nonnegative().nullable().optional(),
|
priceCents: z.number().int().nonnegative().nullable().optional(),
|
||||||
imageUrl: z.string().url().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(),
|
description: z.string().nullable().optional(),
|
||||||
sourceUrl: z.string().url().nullable().optional(),
|
sourceUrl: z.string().url().nullable().optional(),
|
||||||
imageCredit: z.string().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);
|
if (!id) return c.json({ error: "Invalid item ID" }, 400);
|
||||||
const item = await getGlobalItemWithOwnerCount(db, id);
|
const item = await getGlobalItemWithOwnerCount(db, id);
|
||||||
if (!item) return c.json({ error: "Global item not found" }, 404);
|
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
|
// PUT /api/admin/items/:id — update item fields
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ export async function updateGlobalItemById(
|
|||||||
weightGrams?: number | null;
|
weightGrams?: number | null;
|
||||||
priceCents?: number | null;
|
priceCents?: number | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
imageFilename?: string | null;
|
||||||
|
dominantColor?: string | null;
|
||||||
|
cropZoom?: number | null;
|
||||||
|
cropX?: number | null;
|
||||||
|
cropY?: number | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
imageCredit?: string | null;
|
imageCredit?: string | null;
|
||||||
@@ -260,6 +265,13 @@ export async function updateGlobalItemById(
|
|||||||
if ("priceCents" in fields)
|
if ("priceCents" in fields)
|
||||||
updateSet.priceCents = fields.priceCents ?? null;
|
updateSet.priceCents = fields.priceCents ?? null;
|
||||||
if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? 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)
|
if ("description" in fields)
|
||||||
updateSet.description = fields.description ?? null;
|
updateSet.description = fields.description ?? null;
|
||||||
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
|
if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;
|
||||||
|
|||||||
Reference in New Issue
Block a user