diff --git a/src/client/components/CategoryPicker.tsx b/src/client/components/CategoryPicker.tsx new file mode 100644 index 0000000..6d9f6d9 --- /dev/null +++ b/src/client/components/CategoryPicker.tsx @@ -0,0 +1,200 @@ +import { useState, useRef, useEffect } from "react"; +import { + useCategories, + useCreateCategory, +} from "../hooks/useCategories"; + +interface CategoryPickerProps { + value: number; + onChange: (categoryId: number) => void; +} + +export function CategoryPicker({ value, onChange }: CategoryPickerProps) { + const { data: categories = [] } = useCategories(); + const createCategory = useCreateCategory(); + const [inputValue, setInputValue] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Sync display value when value prop changes + const selectedCategory = categories.find((c) => c.id === value); + + const filtered = categories.filter((c) => + c.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + const showCreateOption = + inputValue.trim() !== "" && + !categories.some( + (c) => c.name.toLowerCase() === inputValue.trim().toLowerCase(), + ); + + const totalOptions = filtered.length + (showCreateOption ? 1 : 0); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + // Reset input to selected category name + if (selectedCategory) { + setInputValue(""); + } + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [selectedCategory]); + + function handleSelect(categoryId: number) { + onChange(categoryId); + setInputValue(""); + setIsOpen(false); + setHighlightIndex(-1); + } + + async function handleCreate() { + const name = inputValue.trim(); + if (!name) return; + createCategory.mutate( + { name, emoji: "\u{1F4E6}" }, + { + onSuccess: (newCat) => { + handleSelect(newCat.id); + }, + }, + ); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "Enter") { + setIsOpen(true); + e.preventDefault(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightIndex((i) => Math.min(i + 1, totalOptions - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (highlightIndex >= 0 && highlightIndex < filtered.length) { + handleSelect(filtered[highlightIndex].id); + } else if ( + showCreateOption && + highlightIndex === filtered.length + ) { + handleCreate(); + } + break; + case "Escape": + setIsOpen(false); + setHighlightIndex(-1); + setInputValue(""); + break; + } + } + + // Scroll highlighted option into view + useEffect(() => { + if (highlightIndex >= 0 && listRef.current) { + const option = listRef.current.children[highlightIndex] as HTMLElement; + option?.scrollIntoView({ block: "nearest" }); + } + }, [highlightIndex]); + + return ( +
+ = 0 ? `category-option-${highlightIndex}` : undefined + } + value={ + isOpen + ? inputValue + : selectedCategory + ? `${selectedCategory.emoji} ${selectedCategory.name}` + : "" + } + placeholder="Search or create category..." + onChange={(e) => { + setInputValue(e.target.value); + setIsOpen(true); + setHighlightIndex(-1); + }} + onFocus={() => { + setIsOpen(true); + setInputValue(""); + }} + onKeyDown={handleKeyDown} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {isOpen && ( +
    + {filtered.map((cat, i) => ( +
  • handleSelect(cat.id)} + onMouseEnter={() => setHighlightIndex(i)} + > + {cat.emoji} {cat.name} +
  • + ))} + {showCreateOption && ( +
  • setHighlightIndex(filtered.length)} + > + + Create "{inputValue.trim()}" +
  • + )} + {filtered.length === 0 && !showCreateOption && ( +
  • + No categories found +
  • + )} +
+ )} +
+ ); +} diff --git a/src/client/components/ItemForm.tsx b/src/client/components/ItemForm.tsx new file mode 100644 index 0000000..7d6cc87 --- /dev/null +++ b/src/client/components/ItemForm.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from "react"; +import { useCreateItem, useUpdateItem, useItems } 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; + categoryId: number; + notes: string; + productUrl: string; + imageFilename: string | null; +} + +const INITIAL_FORM: FormData = { + name: "", + weightGrams: "", + priceDollars: "", + 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(INITIAL_FORM); + const [errors, setErrors] = useState>({}); + + // 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) : "", + 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 = {}; + if (!form.name.trim()) { + newErrors.name = "Name is required"; + } + if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) { + newErrors.weightGrams = "Must be a positive number"; + } + if (form.priceDollars && (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, + 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 ( +
+ {/* Name */} +
+ + 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-blue-500 focus:border-transparent" + placeholder="e.g. Osprey Talon 22" + autoFocus + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Weight */} +
+ + + 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-blue-500 focus:border-transparent" + placeholder="e.g. 680" + /> + {errors.weightGrams && ( +

{errors.weightGrams}

+ )} +
+ + {/* Price */} +
+ + + 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-blue-500 focus:border-transparent" + placeholder="e.g. 129.99" + /> + {errors.priceDollars && ( +

{errors.priceDollars}

+ )} +
+ + {/* Category */} +
+ + setForm((f) => ({ ...f, categoryId: id }))} + /> +
+ + {/* Notes */} +
+ +