Files
GearBox/src/client/components/ItemForm.tsx
Jean-Luc Makiola 8c64bf9fbf feat(17-03): update client components to use imageUrl from API responses
- Replace all /uploads/ path construction with imageUrl presigned URLs
- Add imageUrl prop to ItemCard, CandidateCard, CandidateListItem, ComparisonTable
- Update ImageUpload to use presigned URLs + local preview for new uploads
- Pass imageUrl through from parent components (CollectionView, forms, routes)
2026-04-05 12:27:34 +02:00

316 lines
8.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface ItemFormProps {
mode: "add" | "edit";
itemId?: number | null;
}
interface FormData {
name: string;
weightGrams: string;
priceDollars: string;
quantity: number;
categoryId: number;
notes: string;
productUrl: string;
imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
name: "",
weightGrams: "",
priceDollars: "",
quantity: 1,
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
};
export function ItemForm({ mode, itemId }: ItemFormProps) {
const { data: items } = useItems();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const closePanel = useUIStore((s) => s.closePanel);
const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
const [form, setForm] = useState<FormData>(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
// Pre-fill form when editing
useEffect(() => {
if (mode === "edit" && itemId != null && items) {
const item = items.find((i) => i.id === itemId);
if (item) {
setForm({
name: item.name,
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceDollars:
item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
quantity: item.quantity ?? 1,
categoryId: item.categoryId,
notes: item.notes ?? "",
productUrl: item.productUrl ?? "",
imageFilename: item.imageFilename,
});
}
} else if (mode === "add") {
setForm(INITIAL_FORM);
}
}, [mode, itemId, items]);
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) {
newErrors.name = "Name is required";
}
if (
form.weightGrams &&
(Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
) {
newErrors.weightGrams = "Must be a positive number";
}
if (
form.priceDollars &&
(Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
) {
newErrors.priceDollars = "Must be a positive number";
}
if (
form.productUrl &&
form.productUrl.trim() !== "" &&
!form.productUrl.match(/^https?:\/\//)
) {
newErrors.productUrl = "Must be a valid URL (https://...)";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
name: form.name.trim(),
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars
? Math.round(Number(form.priceDollars) * 100)
: undefined,
quantity: form.quantity,
categoryId: form.categoryId,
notes: form.notes.trim() || undefined,
productUrl: form.productUrl.trim() || undefined,
imageFilename: form.imageFilename ?? undefined,
};
if (mode === "add") {
createItem.mutate(payload, {
onSuccess: () => {
setForm(INITIAL_FORM);
closePanel();
},
});
} else if (itemId != null) {
updateItem.mutate(
{ id: itemId, ...payload },
{ onSuccess: () => closePanel() },
);
}
}
const isPending = createItem.isPending || updateItem.isPending;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Image */}
<ImageUpload
value={form.imageFilename}
imageUrl={
mode === "edit" && itemId != null
? items?.find((i) => i.id === itemId)?.imageUrl
: null
}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
{/* Name */}
<div>
<label
htmlFor="item-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name *
</label>
<input
id="item-name"
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. Osprey Talon 22"
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Weight */}
<div>
<label
htmlFor="item-weight"
className="block text-sm font-medium text-gray-700 mb-1"
>
Weight (g)
</label>
<input
id="item-weight"
type="number"
min="0"
step="any"
value={form.weightGrams}
onChange={(e) =>
setForm((f) => ({ ...f, weightGrams: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 680"
/>
{errors.weightGrams && (
<p className="mt-1 text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
{/* Price */}
<div>
<label
htmlFor="item-price"
className="block text-sm font-medium text-gray-700 mb-1"
>
Price ($)
</label>
<input
id="item-price"
type="number"
min="0"
step="0.01"
value={form.priceDollars}
onChange={(e) =>
setForm((f) => ({ ...f, priceDollars: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="e.g. 129.99"
/>
{errors.priceDollars && (
<p className="mt-1 text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
{/* Quantity */}
<div>
<label
htmlFor="item-quantity"
className="block text-sm font-medium text-gray-700 mb-1"
>
Quantity
</label>
<input
id="item-quantity"
type="number"
min="1"
step="1"
value={form.quantity}
onChange={(e) =>
setForm((f) => ({
...f,
quantity: Math.max(1, Number(e.target.value) || 1),
}))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
{/* Notes */}
<div>
<label
htmlFor="item-notes"
className="block text-sm font-medium text-gray-700 mb-1"
>
Notes
</label>
<textarea
id="item-notes"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent resize-none"
placeholder="Any additional notes..."
/>
</div>
{/* Product Link */}
<div>
<label
htmlFor="item-url"
className="block text-sm font-medium text-gray-700 mb-1"
>
Product Link
</label>
<input
id="item-url"
type="url"
value={form.productUrl}
onChange={(e) =>
setForm((f) => ({ ...f, productUrl: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent"
placeholder="https://..."
/>
{errors.productUrl && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isPending}
className="flex-1 py-2.5 px-4 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{isPending
? "Saving..."
: mode === "add"
? "Add Item"
: "Save Changes"}
</button>
{mode === "edit" && itemId != null && (
<button
type="button"
onClick={() => openConfirmDelete(itemId)}
className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
>
Delete
</button>
)}
</div>
</form>
);
}