From e0a152260882a416bf4b7602e3d41728daa951f9 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 18:48:47 -0400 Subject: [PATCH 1/2] Add gardens UI: list page + create/edit/delete (#8) The /gardens page is now home base, backed by the #7 API. - lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions (metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib consolidates. - lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/ useUpdateGarden/useDeleteGarden (mutations invalidate the list) and conflictGarden() to pull the fresh row out of a 409. - GardensPage: loading/empty/error states; empty state prompts a first garden; responsive card grid (single column on phones). - GardenCard: name, dimensions formatted per the garden's unit, notes preview; body links into /gardens/:id, edit/delete kept out of the link. - GardenFormModal: create/edit with a unit selector; dimensions are entered in the chosen unit and converted to cm (switching units converts the current values). A 409 rebases the form onto the server's fresh row. - DeleteGardenModal: confirmation before removing. - ui/: Modal (Escape/backdrop close), TextArea, Select. Verified in a real browser against the embedded binary: create appears without reload; edit persists (version 2), form prefilled from stored cm converted back to the garden's unit; delete confirms then removes -> empty state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows "4' 0" x 8' 0"". tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- .../components/gardens/DeleteGardenModal.tsx | 47 ++++++ web/src/components/gardens/GardenCard.tsx | 49 ++++++ .../components/gardens/GardenFormModal.tsx | 156 ++++++++++++++++++ web/src/components/ui/Modal.tsx | 48 ++++++ web/src/components/ui/Select.tsx | 40 +++++ web/src/components/ui/TextArea.tsx | 35 ++++ web/src/lib/gardens.ts | 85 ++++++++++ web/src/lib/units.ts | 57 +++++++ web/src/pages/GardensPage.tsx | 57 ++++++- 9 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 web/src/components/gardens/DeleteGardenModal.tsx create mode 100644 web/src/components/gardens/GardenCard.tsx create mode 100644 web/src/components/gardens/GardenFormModal.tsx create mode 100644 web/src/components/ui/Modal.tsx create mode 100644 web/src/components/ui/Select.tsx create mode 100644 web/src/components/ui/TextArea.tsx create mode 100644 web/src/lib/gardens.ts create mode 100644 web/src/lib/units.ts diff --git a/web/src/components/gardens/DeleteGardenModal.tsx b/web/src/components/gardens/DeleteGardenModal.tsx new file mode 100644 index 0000000..1c374b0 --- /dev/null +++ b/web/src/components/gardens/DeleteGardenModal.tsx @@ -0,0 +1,47 @@ +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 { useDeleteGarden, type Garden } from '@/lib/gardens' + +/** Confirmation dialog for deleting a garden (and everything in it). */ +export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { + const del = useDeleteGarden() + const [error, setError] = useState(null) + + async function onConfirm() { + setError(null) + try { + await del.mutateAsync(garden.id) + onClose() + } catch (err) { + setError(errorMessage(err, 'Could not delete the garden.')) + } + } + + return ( + +
+

+ Delete {garden.name} and everything planned in it? + This can't be undone. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx new file mode 100644 index 0000000..1f34424 --- /dev/null +++ b/web/src/components/gardens/GardenCard.tsx @@ -0,0 +1,49 @@ +import { Link } from '@tanstack/react-router' +import type { Garden } from '@/lib/gardens' +import { formatDimensions } from '@/lib/units' + +/** + * One garden as a card: the body links into the editor (/gardens/:id); the + * footer has edit/delete actions (kept out of the link so they don't navigate). + */ +export function GardenCard({ + garden, + onEdit, + onDelete, +}: { + garden: Garden + onEdit: () => void + onDelete: () => void +}) { + return ( +
+ +

{garden.name}

+

+ {formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)} +

+ {garden.notes &&

{garden.notes}

} + +
+ + +
+
+ ) +} diff --git a/web/src/components/gardens/GardenFormModal.tsx b/web/src/components/gardens/GardenFormModal.tsx new file mode 100644 index 0000000..7a4db68 --- /dev/null +++ b/web/src/components/gardens/GardenFormModal.tsx @@ -0,0 +1,156 @@ +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 { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' +import { cmFromDisplay, dimensionUnitLabel, displayFromCm, type UnitPref } from '@/lib/units' + +const DEFAULT_METERS = 10 // matches the server's 10 m default + +const unitOptions = [ + { value: 'metric', label: 'Metric (m)' }, + { value: 'imperial', label: 'Imperial (ft)' }, +] + +function dimString(cm: number | undefined, unit: UnitPref): string { + return cm === undefined ? String(DEFAULT_METERS) : String(displayFromCm(cm, unit)) +} + +/** + * Create (no garden) or edit (garden given) form. Dimensions are entered in the + * selected unit and converted to centimeters for the API; switching units + * converts the current values so the physical size is preserved. A 409 rebases + * the form onto the server's fresh row. + */ +export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) { + const isEdit = !!garden + const create = useCreateGarden() + const update = useUpdateGarden(garden?.id ?? 0) + const pending = create.isPending || update.isPending + + const [name, setName] = useState(garden?.name ?? '') + const [unit, setUnit] = useState(garden?.unitPref ?? 'metric') + const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric')) + const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric')) + const [notes, setNotes] = useState(garden?.notes ?? '') + const [version, setVersion] = useState(garden?.version ?? 0) + const [conflict, setConflict] = useState(null) + const [formError, setFormError] = useState(null) + + function changeUnit(next: UnitPref) { + const convert = (s: string) => { + const v = parseFloat(s) + return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s + } + setWidth(convert(width)) + setHeight(convert(height)) + setUnit(next) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setFormError(null) + setConflict(null) + + const w = parseFloat(width) + const h = parseFloat(height) + if (!name.trim() || !Number.isFinite(w) || w <= 0 || !Number.isFinite(h) || h <= 0) { + setFormError('Enter a name and a positive width and height.') + return + } + + const input = { + name: name.trim(), + widthCm: cmFromDisplay(w, unit), + heightCm: cmFromDisplay(h, unit), + unitPref: unit, + notes: notes.trim(), + } + + try { + if (isEdit) { + await update.mutateAsync({ ...input, version }) + } else { + await create.mutateAsync(input) + } + onClose() + } catch (err) { + const current = conflictGarden(err) + if (current) { + // Someone else changed this garden: rebase the form onto the fresh row so + // a re-save applies against the current version. + setVersion(current.version) + setName(current.name) + setUnit(current.unitPref) + setWidth(String(displayFromCm(current.widthCm, current.unitPref))) + setHeight(String(displayFromCm(current.heightCm, current.unitPref))) + setNotes(current.notes) + setConflict('This garden changed elsewhere. The latest values are shown — review and save again.') + return + } + setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.')) + } + } + + const unitLabel = dimensionUnitLabel(unit) + + return ( + +
+ {conflict && {conflict}} + + setName(e.target.value)} /> + +