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
+55 -2
View File
@@ -1,5 +1,58 @@
import { PageStub } from '@/components/PageStub'
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
import { GardenCard } from '@/components/gardens/GardenCard'
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
import { useGardens, type Garden } from '@/lib/gardens'
// Which modal is open, if any. `edit`/`delete` carry the target garden.
type Dialog = { kind: 'create' } | { kind: 'edit'; garden: Garden } | { kind: 'delete'; garden: Garden } | null
export function GardensPage() {
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
const gardens = useGardens()
const [dialog, setDialog] = useState<Dialog>(null)
return (
<section>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Gardens</h1>
{gardens.data && gardens.data.length > 0 && (
<Button onClick={() => setDialog({ kind: 'create' })}>New garden</Button>
)}
</div>
<div className="mt-6">
{gardens.isPending && <p className="text-sm text-muted">Loading your gardens</p>}
{gardens.isError && <Alert>Could not load your gardens. Please refresh.</Alert>}
{gardens.isSuccess && gardens.data.length === 0 && (
<div className="rounded-xl border border-dashed border-border p-10 text-center">
<p className="text-fg">No gardens yet.</p>
<p className="mt-1 text-sm text-muted">Create your first garden to start planning.</p>
<Button className="mt-4" onClick={() => setDialog({ kind: 'create' })}>
Create your first garden
</Button>
</div>
)}
{gardens.isSuccess && gardens.data.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{gardens.data.map((g) => (
<GardenCard
key={g.id}
garden={g}
onEdit={() => setDialog({ kind: 'edit', garden: g })}
onDelete={() => setDialog({ kind: 'delete', garden: g })}
/>
))}
</div>
)}
</div>
{dialog?.kind === 'create' && <GardenFormModal onClose={() => setDialog(null)} />}
{dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={() => setDialog(null)} />}
{dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={() => setDialog(null)} />}
</section>
)
}