Add gardens UI: list page + create/edit/delete (#8)
Build image / build-and-push (push) Successful in 13s
Gadfly review (reusable) / review (pull_request) Successful in 7m21s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m21s

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 18:48:47 -04:00
co-authored by Claude Opus 4.8
parent 67c48162c3
commit e0a1522608
9 changed files with 572 additions and 2 deletions
@@ -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<string | null>(null)
async function onConfirm() {
setError(null)
try {
await del.mutateAsync(garden.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not delete the garden.'))
}
}
return (
<Modal title="Delete garden" onClose={onClose}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
This can't be undone.
</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
Cancel
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={del.isPending}
className="bg-red-600 text-white hover:bg-red-700"
>
{del.isPending ? 'Deleting' : 'Delete'}
</Button>
</div>
</div>
</Modal>
)
}
+49
View File
@@ -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 (
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
<Link
to="/gardens/$gardenId"
params={{ gardenId: String(garden.id) }}
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
<p className="mt-1 text-sm text-muted">
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
</p>
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
</Link>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
<button
type="button"
onClick={onEdit}
className="rounded-md px-2.5 py-1 text-sm font-medium text-muted transition-colors hover:bg-border/50 hover:text-fg"
>
Edit
</button>
<button
type="button"
onClick={onDelete}
className="rounded-md px-2.5 py-1 text-sm font-medium text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
>
Delete
</button>
</div>
</div>
)
}
@@ -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<UnitPref>(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<string | null>(null)
const [formError, setFormError] = useState<string | null>(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 (
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose}>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
{conflict && <Alert tone="info">{conflict}</Alert>}
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
<Select
label="Units"
name="unitPref"
value={unit}
onChange={(e) => changeUnit(e.target.value as UnitPref)}
options={unitOptions}
/>
<div className="grid grid-cols-2 gap-3">
<TextField
label={`Width (${unitLabel})`}
name="width"
type="number"
inputMode="decimal"
step="any"
min="0"
required
value={width}
onChange={(e) => setWidth(e.target.value)}
/>
<TextField
label={`Height (${unitLabel})`}
name="height"
type="number"
inputMode="decimal"
step="any"
min="0"
required
value={height}
onChange={(e) => setHeight(e.target.value)}
/>
</div>
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{formError && <Alert>{formError}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
</Button>
</div>
</form>
</Modal>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef, type ReactNode } from 'react'
/**
* A centered modal dialog rendered over a dimmed backdrop. Closes on Escape or a
* backdrop click. Wraps content in a native <dialog>-like card; the caller owns
* the open/closed state (render it only when open).
*/
export function Modal({
title,
onClose,
children,
}: {
title: string
onClose: () => void
children: ReactNode
}) {
const cardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
cardRef.current?.focus()
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
ref={cardRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-lg outline-none"
>
<h2 className="text-lg font-semibold tracking-tight text-fg">{title}</h2>
<div className="mt-4">{children}</div>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef, useId, type SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string
options: { value: string; label: string }[]
}
// Labelled native <select>, styled to match the text inputs.
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ label, options, id, name, className, ...props },
ref,
) {
const generatedId = useId()
const fieldId = id ?? name ?? generatedId
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
{label}
</label>
<select
ref={ref}
id={fieldId}
name={name}
className={cn(
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg',
'outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60',
className,
)}
{...props}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)
})
+35
View File
@@ -0,0 +1,35 @@
import { forwardRef, useId, type TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string
}
// Labelled multi-line input, matching TextField's styling (16px text avoids iOS
// zoom). The id falls back to name then a generated id so the label always binds.
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{ label, id, name, className, ...props },
ref,
) {
const generatedId = useId()
const fieldId = id ?? name ?? generatedId
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
{label}
</label>
<textarea
ref={ref}
id={fieldId}
name={name}
className={cn(
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg',
'placeholder:text-muted/70 outline-none transition-colors',
'focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60',
className,
)}
{...props}
/>
</div>
)
})