From 74d3e3704ae9157d70b8c3e31d1b829b9ea029d7 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 21:55:12 -0400 Subject: [PATCH] Add plant catalog UI: /plants page + PlantPicker (#13) - web/src/lib/plants.ts: zod-validated Plant schema + react-query hooks (list/create/update/delete) over the #12 endpoints, with the category enum, labels, isBuiltin helper, and a 409 conflict extractor. - /plants page: searchable, category-chip-filtered grid of PlantCard; built-ins badged and offer only Duplicate; own plants add Edit/Delete. A local metric/imperial spacing toggle (no per-user unit pref exists server-side). - PlantFormModal: create/edit/duplicate a custom plant (name, category, spacing, color, emoji icon, days-to-maturity, notes), unit-aware spacing, 409 rebase. - DeletePlantModal: confirm + surface the server's PLANT_IN_USE refusal inline. - editor/PlantPicker.tsx: reusable search-first chooser with recently-used (localStorage) and category filter, bottom-sheet on mobile / panel on desktop, onSelect callback. Demoed via the /plants "Try the picker" button until #15 consumes it. - units.ts: small-scale spacing helpers (cm/inches) + tests. tsc --noEmit clean; 20/20 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- .../components/plants/DeletePlantModal.tsx | 46 ++++ web/src/components/plants/PlantCard.tsx | 77 +++++++ web/src/components/plants/PlantFormModal.tsx | 213 ++++++++++++++++++ web/src/editor/PlantPicker.tsx | 180 +++++++++++++++ web/src/lib/plants.ts | 108 +++++++++ web/src/lib/units.test.ts | 29 ++- web/src/lib/units.ts | 26 +++ web/src/pages/PlantsPage.tsx | 155 ++++++++++++- 8 files changed, 831 insertions(+), 3 deletions(-) create mode 100644 web/src/components/plants/DeletePlantModal.tsx create mode 100644 web/src/components/plants/PlantCard.tsx create mode 100644 web/src/components/plants/PlantFormModal.tsx create mode 100644 web/src/editor/PlantPicker.tsx create mode 100644 web/src/lib/plants.ts diff --git a/web/src/components/plants/DeletePlantModal.tsx b/web/src/components/plants/DeletePlantModal.tsx new file mode 100644 index 0000000..69e1798 --- /dev/null +++ b/web/src/components/plants/DeletePlantModal.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { errorMessage } from '@/lib/api' +import { useDeletePlant, type Plant } from '@/lib/plants' + +/** + * Confirmation dialog for deleting a custom plant. A plant still used by + * plantings is refused by the server (409 PLANT_IN_USE); we surface that + * message inline rather than pretending it worked. + */ +export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) { + const deletion = useDeletePlant() + const [error, setError] = useState(null) + + async function onConfirm() { + setError(null) + try { + await deletion.mutateAsync(plant.id) + onClose() + } catch (err) { + setError(errorMessage(err, 'Could not delete the plant.')) + } + } + + return ( + +
+

+ Delete {plant.name} from your catalog? This can't be + undone. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx new file mode 100644 index 0000000..55ebba7 --- /dev/null +++ b/web/src/components/plants/PlantCard.tsx @@ -0,0 +1,77 @@ +import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants' +import { formatSpacing, type UnitPref } from '@/lib/units' + +const actionClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40' +const dangerClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400' + +/** + * One catalog plant as a card: icon tile tinted with the plant's color, name, + * category + mature spacing (unit-aware), and actions. Built-ins are badged and + * offer only "Duplicate" (they're read-only); own plants add Edit/Delete. + */ +export function PlantCard({ + plant, + unit, + onEdit, + onDelete, + onDuplicate, +}: { + plant: Plant + unit: UnitPref + onEdit: () => void + onDelete: () => void + onDuplicate: () => void +}) { + const builtin = isBuiltin(plant) + return ( +
+
+ + {plant.icon} + +
+
+

{plant.name}

+ {builtin && ( + + Built-in + + )} +
+

+ {CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing +

+ {plant.notes &&

{plant.notes}

} +
+ +
+
+ + {!builtin && ( + + )} + {!builtin && ( + + )} +
+
+ ) +} diff --git a/web/src/components/plants/PlantFormModal.tsx b/web/src/components/plants/PlantFormModal.tsx new file mode 100644 index 0000000..fdc94fc --- /dev/null +++ b/web/src/components/plants/PlantFormModal.tsx @@ -0,0 +1,213 @@ +import { useState, type FormEvent } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' +import { TextArea } from '@/components/ui/TextArea' +import { TextField } from '@/components/ui/TextField' +import { errorMessage } from '@/lib/api' +import { + CATEGORY_LABELS, + PLANT_CATEGORIES, + conflictPlant, + useCreatePlant, + useUpdatePlant, + type Plant, + type PlantCategory, + type PlantInput, +} from '@/lib/plants' +import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' + +const DEFAULT_COLOR = '#4a7c3f' +const DEFAULT_ICON = '🌱' + +const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })) + +/** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */ +function expandHex(color: string): string { + if (/^#[0-9a-fA-F]{3}$/.test(color)) { + const [, r, g, b] = color + return `#${r}${r}${g}${g}${b}${b}` + } + return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR +} + +/** + * Create or edit a custom plant. `plant` puts it in edit mode; `template` (a + * built-in or another plant, used by "Duplicate") pre-fills a fresh create. A + * 409 rebases the form onto the server's current row. Spacing is entered in the + * page's unit and converted to centimeters for the API. + */ +export function PlantFormModal({ + plant, + template, + unit, + onClose, +}: { + plant?: Plant + template?: Plant + unit: UnitPref + onClose: () => void +}) { + const isEdit = !!plant + const source = plant ?? template + const create = useCreatePlant() + const update = useUpdatePlant() + const pending = create.isPending || update.isPending + + const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') + const [category, setCategory] = useState(source?.category ?? 'vegetable') + const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit))) + const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR)) + const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON) + const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') + const [notes, setNotes] = useState(source?.notes ?? '') + const [version, setVersion] = useState(plant?.version ?? 0) + const [conflict, setConflict] = useState(null) + const [formError, setFormError] = useState(null) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setFormError(null) + setConflict(null) + + if (!name.trim()) { + setFormError('Enter a name for the plant.') + return + } + if (!icon.trim()) { + setFormError('Pick an emoji icon.') + return + } + const spacingCm = cmFromSpacing(parseFloat(spacing), unit) + if (!Number.isFinite(spacingCm) || spacingCm < 1) { + setFormError('Spacing must be a positive number.') + return + } + let daysToMaturity: number | null = null + if (days.trim()) { + const d = parseInt(days, 10) + if (!Number.isInteger(d) || d < 1) { + setFormError('Days to maturity must be a whole number of days, or left blank.') + return + } + daysToMaturity = d + } + + const input: PlantInput = { + name: name.trim(), + category, + spacingCm, + color, + icon: icon.trim(), + daysToMaturity, + notes: notes.trim(), + } + + try { + if (isEdit) { + await update.mutateAsync({ id: plant.id, ...input, version }) + } else { + await create.mutateAsync(input) + } + onClose() + } catch (err) { + const current = conflictPlant(err) + if (current) { + // Someone else changed this plant: rebase onto the fresh row so a re-save + // applies against the current version. + setVersion(current.version) + setName(current.name) + setCategory(current.category) + setSpacing(String(spacingFromCm(current.spacingCm, unit))) + setColor(expandHex(current.color)) + setIcon(current.icon) + setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') + setNotes(current.notes) + setConflict('This plant changed elsewhere. The latest values are shown — review and save again.') + return + } + setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.')) + } + } + + const unitLabel = spacingUnitLabel(unit) + + return ( + +
+ {conflict && {conflict}} + + setName(e.target.value)} /> + +
+ setColor(e.target.value)} + className="h-10 w-full cursor-pointer rounded-md border border-border bg-surface" + /> +
+ setIcon(e.target.value)} + hint="A single emoji, e.g. 🍅" + /> + + + setDays(e.target.value)} + /> + +